import createError from '@fastify/error'
import {
  createEnvFileTool,
  defaultPackageManager,
  findConfigurationFile,
  generateDashedName,
  kMetadata,
  loadConfiguration,
  loadConfigurationFile,
  safeRemove
} from '@platformatic/foundation'
import { BaseGenerator, envObjectToString, getApplicationTemplateFromSchemaUrl } from '@platformatic/generators'
import { existsSync } from 'node:fs'
import { readFile, readdir, stat } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { basename, join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { transform } from './config.js'
import { schema } from './schema.js'
import { getArrayDifference } from './utils.js'

const wrappableProperties = {
  logger: {
    level: '{PLT_SERVER_LOGGER_LEVEL}'
  },
  server: {
    hostname: '{PLT_SERVER_HOSTNAME}',
    port: '{PORT}'
  },
  managementApi: '{PLT_MANAGEMENT_API}'
}

const engines = {
  node: '>=22.19.0'
}

export const ERROR_PREFIX = 'PLT_RUNTIME_GEN'

const NoApplicationNamedError = createError(
  `${ERROR_PREFIX}_NO_APPLICATION_FOUND`,
  "No application named '%s' has been added to this runtime."
)
const NoEntryPointError = createError(`${ERROR_PREFIX}_NO_ENTRYPOINT`, 'No entrypoint had been defined.')

function getRuntimeBaseEnvVars (config) {
  return {
    PLT_SERVER_HOSTNAME: '127.0.0.1',
    PORT: config.port || 3042,
    PLT_SERVER_LOGGER_LEVEL: config.logLevel || 'info',
    PLT_MANAGEMENT_API: true
  }
}

export class RuntimeGenerator extends BaseGenerator {
  constructor (opts) {
    super({
      ...opts,
      module: '@platformatic/runtime'
    })
    this.runtimeName = opts.name
    this.applicationsFolder = opts.applicationsFolder ?? 'applications'
    this.applications = []
    this.existingApplications = []
    this.entryPoint = null
    this.packageManager = opts.packageManager ?? defaultPackageManager
  }

  async addApplication (application, name) {
    // ensure application config is correct
    const originalConfig = application.config
    const applicationName = name || generateDashedName()
    const newConfig = {
      ...originalConfig,
      isRuntimeContext: true,
      applicationName
    }
    // reset all files previously generated by the application
    application.reset()
    application.setConfig(newConfig)
    this.applications.push({
      name: applicationName,
      application
    })

    application.setRuntime(this)
  }

  setEntryPoint (entryPoint) {
    const application =
      this.existingApplications.includes(entryPoint) || this.applications.find(svc => svc.name === entryPoint)
    if (!application) {
      throw new NoApplicationNamedError(entryPoint)
    }
    this.entryPoint = application
  }

  async generatePackageJson () {
    const template = {
      name: `${this.runtimeName}`,
      scripts: {
        dev: this.config.devCommand ?? 'wattpm dev',
        build: this.config.buildCommand ?? 'wattpm build',
        start: this.config.startCommand ?? 'wattpm start'
      },
      dependencies: {
        wattpm: `^${this.platformaticVersion}`,
        ...this.config.dependencies
      },
      engines
    }

    if (this.packageManager === 'npm' || this.packageManager === 'yarn') {
      template.workspaces = [this.applicationsFolder + '/*']
    }

    return template
  }

  async _beforePrepare () {
    this.setApplicationsDirectory()
    this.setApplicationsConfigValues()
    this.addApplicationsDependencies()

    this.addEnvVars(getRuntimeBaseEnvVars(this.config), { overwrite: false, default: true })
  }

  addApplicationsDependencies () {
    this.applications.forEach(({ application }) => {
      if (application.config.dependencies) {
        Object.entries(application.config.dependencies).forEach(kv => {
          this.config.dependencies[kv[0]] = kv[1]
        })
      }
    })
  }

  async populateFromExistingConfig () {
    if (this._hasCheckedForExistingConfig) {
      return
    }
    this._hasCheckedForExistingConfig = true
    const existingConfigFile = this.runtimeConfig ?? (await findConfigurationFile(this.targetDirectory, 'runtime'))
    if (existingConfigFile && existsSync(join(this.targetDirectory, existingConfigFile))) {
      this.existingConfigRaw = await loadConfigurationFile(join(this.targetDirectory, existingConfigFile))
      this.existingConfig = await loadConfiguration(join(this.targetDirectory, existingConfigFile), schema, {
        transform,
        ignoreProcessEnv: true
      })

      const { PLT_ROOT, ...existingEnvironment } = this.existingConfig[kMetadata].env
      this.config.env = existingEnvironment
      this.config.port = this.config.env.PORT
      this.entryPoint = this.existingConfig.applications.find(svc => svc.entrypoint)
      this.existingApplications = this.existingConfig.applications.map(s => s.id)

      this.updateRuntimeConfig(this.existingConfigRaw)
      this.updateRuntimeEnv(await readFile(join(this.targetDirectory, '.env'), 'utf-8'))
    }
  }

  async prepare () {
    await this.populateFromExistingConfig()
    if (this.existingConfig) {
      this.setApplicationsDirectory()
      this.setApplicationsConfigValues()
      await this._afterPrepare()
      return {
        env: this.config.env,
        targetDirectory: this.targetDirectory
      }
    } else {
      return await super.prepare()
    }
  }

  setApplicationsConfigValues () {
    this.applications.forEach(({ application }) => {
      if (!application.config) {
        // set default config
        application.setConfig()
      }
    })
  }

  async _getConfigFileContents () {
    const config = {
      $schema: `https://schemas.platformatic.dev/wattpm/${this.platformaticVersion}.json`,
      entrypoint: this.entryPoint.name,
      watch: true,
      autoload: {
        path: this.config.autoload || this.applicationsFolder,
        exclude: ['docs']
      },
      ...wrappableProperties
    }

    return config
  }

  async _afterPrepare () {
    if (!this.entryPoint) {
      throw new NoEntryPointError()
    }
    const applicationsEnv = await this.prepareApplicationFiles()
    this.addEnvVars({
      ...this.config.env,
      ...this.getRuntimeEnv(),
      ...applicationsEnv
    })

    this.updateRuntimeEnv(envObjectToString(this.config.env))

    this.addFile({
      path: '',
      file: '.env.sample',
      contents: envObjectToString(this.config.defaultEnv)
    })

    return {
      targetDirectory: this.targetDirectory,
      env: applicationsEnv
    }
  }

  async writeFiles () {
    for (const { application } of this.applications) {
      await application._beforeWriteFiles?.(this)
    }

    await super.writeFiles()

    if (!this.config.isUpdating) {
      for (const { application } of this.applications) {
        await application.writeFiles()
      }
    }

    for (const { application } of this.applications) {
      await application._afterWriteFiles?.(this)
    }
  }

  async prepareQuestions () {
    await this.populateFromExistingConfig()

    if (this.existingConfig) {
      return
    }

    // port
    this.questions.push({
      type: 'input',
      name: 'port',
      default: 3042,
      message: 'What port do you want to use?'
    })
  }

  setApplicationsDirectory () {
    this.applications.forEach(({ application }) => {
      if (!application.config) {
        // set default config
        application.setConfig()
      }
      let basePath
      if (this.existingConfig) {
        basePath = this.existingConfig.autoload.path
      } else {
        basePath = join(this.targetDirectory, this.config.autoload || this.applicationsFolder)
      }
      this.applicationsBasePath = basePath
      application.setTargetDirectory(join(basePath, application.config.applicationName))
    })
  }

  setApplicationsConfig (configToOverride) {
    this.applications.forEach(application => {
      const originalConfig = application.config
      application.setConfig({
        ...originalConfig,
        ...configToOverride
      })
    })
  }

  async prepareApplicationFiles () {
    let applicationsEnv = {}
    for (const svc of this.applications) {
      const svcEnv = await svc.application.prepare()
      applicationsEnv = {
        ...applicationsEnv,
        ...svcEnv.env
      }
    }
    return applicationsEnv
  }

  getConfigFieldsDefinitions () {
    return []
  }

  setConfigFields () {
    // do nothing, makes no sense
  }

  getRuntimeEnv () {
    return {
      PORT: this.config.port
    }
  }

  async postInstallActions () {
    for (const { application } of this.applications) {
      await application.postInstallActions()
    }
  }

  async _getGeneratorForTemplate (dir, pkg) {
    const _require = createRequire(dir)
    const fileToImport = _require.resolve(pkg)
    return (await import(pathToFileURL(fileToImport))).Generator
  }

  async loadFromDir () {
    const output = {
      applications: []
    }
    const runtimePkgConfigFileData = JSON.parse(await readFile(join(this.targetDirectory, this.runtimeConfig), 'utf-8'))
    const applicationsPath = join(this.targetDirectory, runtimePkgConfigFileData.autoload.path)

    // load all applications
    const allApplications = await readdir(applicationsPath)
    for (const s of allApplications) {
      // check is a directory
      const currentApplicationPath = join(applicationsPath, s)
      const dirStat = await stat(currentApplicationPath)
      if (dirStat.isDirectory()) {
        // load the application config
        const configFile = await findConfigurationFile(currentApplicationPath)
        const applicationPltJson = JSON.parse(await readFile(join(currentApplicationPath, configFile), 'utf-8'))
        // get module to load
        const template = applicationPltJson.module || getApplicationTemplateFromSchemaUrl(applicationPltJson.$schema)
        const Generator = await this._getGeneratorForTemplate(currentApplicationPath, template)
        const instance = new Generator({
          logger: this.logger
        })
        this.addApplication(instance, s)
        output.applications.push(await instance.loadFromDir(s, this.targetDirectory))
      }
    }
    return output
  }

  async update (newConfig) {
    let allApplicationsDependencies = {}
    const runtimeAddedEnvKeys = []

    this.config.isUpdating = true
    const currrentPackageJson = JSON.parse(await readFile(join(this.targetDirectory, 'package.json'), 'utf-8'))
    const currentRuntimeDependencies = currrentPackageJson.dependencies
    // check all applications are present with the same template
    const allCurrentApplicationsNames = this.applications.map(s => s.name)
    const allNewApplicationsNames = newConfig.applications.map(s => s.name)
    // load env file tool
    const envTool = createEnvFileTool({
      path: join(this.targetDirectory, '.env')
    })

    await envTool.load()

    const removedApplications = getArrayDifference(allCurrentApplicationsNames, allNewApplicationsNames)
    if (removedApplications.length > 0) {
      for (const removedApplication of removedApplications) {
        // handle application delete

        // delete env variables
        const s = this.applications.find(f => f.name === removedApplication)
        const allKeys = envTool.getKeys()
        allKeys.forEach(k => {
          if (k.startsWith(`PLT_${s.application.config.envPrefix}`)) {
            envTool.deleteKey(k)
          }
        })

        // delete dependencies
        const applicationPath = join(this.targetDirectory, this.applicationsFolder, s.name)
        const configFile = await findConfigurationFile(applicationPath)
        const applicationPackageJson = JSON.parse(await readFile(join(applicationPath, configFile), 'utf-8'))
        if (applicationPackageJson.plugins && applicationPackageJson.plugins.packages) {
          applicationPackageJson.plugins.packages.forEach(p => {
            delete currrentPackageJson.dependencies[p.name]
          })
        }
        // delete directory
        await safeRemove(join(this.targetDirectory, this.applicationsFolder, s.name))
      }
      // throw new CannotRemoveApplicationOnUpdateError(removedApplications.join(', '))
    }

    // handle new applications
    for (const newApplication of newConfig.applications) {
      // create generator for the application
      const ApplicationGenerator = await this._getGeneratorForTemplate(
        join(this.targetDirectory, 'package.json'),
        newApplication.template
      )
      const applicationInstance = new ApplicationGenerator({
        logger: this.logger
      })
      const baseConfig = {
        isRuntimeContext: true,
        targetDirectory: join(this.targetDirectory, this.applicationsFolder, newApplication.name),
        applicationName: newApplication.name,
        plugin: true
      }
      if (allCurrentApplicationsNames.includes(newApplication.name)) {
        // update existing applications env values
        // otherwise, is a new application
        baseConfig.isUpdating = true

        // handle application's plugin differences
        const oldApplicationMetadata = await applicationInstance.loadFromDir(newApplication.name, this.targetDirectory)
        const oldApplicationPackages = oldApplicationMetadata.plugins.map(meta => meta.name)
        const newApplicationPackages = newApplication.plugins.map(meta => meta.name)
        const pluginsToRemove = getArrayDifference(oldApplicationPackages, newApplicationPackages)
        pluginsToRemove.forEach(p => delete currentRuntimeDependencies[p])
      } else {
        // add application to the generator
        this.applications.push({
          name: newApplication.name,
          application: applicationInstance
        })
      }
      applicationInstance.setConfig(baseConfig)
      applicationInstance.setConfigFields(newApplication.fields)

      const applicationEnvPrefix = `PLT_${applicationInstance.config.envPrefix}`
      for (const plug of newApplication.plugins) {
        await applicationInstance.addPackage(plug)
        for (const opt of plug.options) {
          const key = `${applicationEnvPrefix}_${opt.name}`
          runtimeAddedEnvKeys.push(key)
          const value = opt.value
          if (envTool.hasKey(key)) {
            envTool.updateKey(key, value)
          } else {
            envTool.addKey(key, value)
          }
        }
      }
      allApplicationsDependencies = { ...allApplicationsDependencies, ...applicationInstance.config.dependencies }
      const afterPrepareMetadata = await applicationInstance.prepare()
      await applicationInstance.writeFiles()
      // cleanup runtime env removing keys not present anymore in application plugins
      const allKeys = envTool.getKeys()
      allKeys.forEach(k => {
        if (k.startsWith(`${applicationEnvPrefix}_FST_PLUGIN`) && !runtimeAddedEnvKeys.includes(k)) {
          envTool.deleteKey(k)
        }
      })

      // add application env variables to runtime env
      Object.entries(afterPrepareMetadata.env).forEach(([key, value]) => {
        envTool.addKey(key, value)
      })
    }
    // update runtime package.json dependencies
    currrentPackageJson.dependencies = {
      ...currrentPackageJson.dependencies,
      ...allApplicationsDependencies
    }
    this.addFile({
      path: '',
      file: 'package.json',
      contents: JSON.stringify(currrentPackageJson, null, 2)
    })

    // set new entrypoint if specified
    const newEntrypoint = newConfig.entrypoint
    if (newEntrypoint) {
      // load platformatic.json runtime config
      const runtimePkgConfigFileData = JSON.parse(
        await readFile(join(this.targetDirectory, this.runtimeConfig), 'utf-8')
      )

      this.setEntryPoint(newEntrypoint)
      runtimePkgConfigFileData.entrypoint = newEntrypoint
      this.updateRuntimeConfig(runtimePkgConfigFileData)
    }
    await this.writeFiles()
    // save new env
    await envTool.save()
  }

  async generateConfigFile () {
    this.updateRuntimeConfig(await super.generateConfigFile())
  }

  async generateEnv () {
    const serialized = await super.generateEnv()

    if (serialized) {
      this.updateRuntimeEnv(serialized)
    }
  }

  getRuntimeConfigFileObject () {
    return this.files.find(file => file.tags?.includes('runtime-config')) ?? null
  }

  getRuntimeEnvFileObject () {
    return this.files.find(file => file.tags?.includes('runtime-env')) ?? null
  }

  updateRuntimeConfig (config) {
    this.addFile({
      path: '',
      file: this.runtimeConfig,
      contents: JSON.stringify(config, null, 2),
      tags: ['runtime-config']
    })
  }

  updateRuntimeEnv (contents) {
    this.addFile({
      path: '',
      file: '.env',
      contents,
      tags: ['runtime-env']
    })
  }

  updateConfigEntryPoint (entrypoint) {
    // This can return null if the generator was not supposed to modify the config
    const configObject = this.getRuntimeConfigFileObject()
    const config = JSON.parse(configObject.contents)
    config.entrypoint = entrypoint

    this.updateRuntimeConfig(config)
  }
}

export class WrappedGenerator extends BaseGenerator {
  async prepare () {
    await this.getPlatformaticVersion()
    await this.#updateEnvironment()
    await this.#updatePackageJson()
    await this.#createConfigFile()
  }

  async #updateEnvironment () {
    this.addEnvVars(getRuntimeBaseEnvVars(this.config), { overwrite: false, default: true })

    this.addFile({
      path: '',
      file: '.env',
      contents: (await this.#readExistingFile('.env', '', '\n')) + envObjectToString(this.config.env)
    })

    this.addFile({
      path: '',
      file: '.env.sample',
      contents: (await this.#readExistingFile('.env.sample', '', '\n')) + envObjectToString(this.config.defaultEnv)
    })
  }

  async #updatePackageJson () {
    // Manipulate the package.json, if any
    const packageJson = JSON.parse(await this.#readExistingFile('package.json', '{}'))
    let { name, dependencies, devDependencies, scripts, engines: packageJsonEngines, ...rest } = packageJson

    // Add the dependencies
    dependencies = {
      ...dependencies,
      [this.module]: `^${this.platformaticVersion}`,
      platformatic: `^${this.platformaticVersion}`,
      wattpm: `^${this.platformaticVersion}`
    }

    // For easier readbility, sort dependencies and devDependencies by name
    dependencies = Object.fromEntries(Object.entries(dependencies).sort(([a], [b]) => a.localeCompare(b)))
    devDependencies = Object.fromEntries(Object.entries(devDependencies ?? {}).sort(([a], [b]) => a.localeCompare(b)))

    scripts ??= {}
    scripts.dev ??= this.config.devCommand
    scripts.build ??= this.config.buildCommand
    scripts.start ??= this.config.startCommand ?? 'wattpm start'

    this.addFile({
      path: '',
      file: 'package.json',
      contents: JSON.stringify(
        {
          name: name ?? this.projectName ?? this.runtimeName ?? basename(this.targetDirectory),
          scripts,
          dependencies,
          devDependencies,
          ...rest,
          engines: { ...packageJsonEngines, ...engines }
        },
        null,
        2
      )
    })
  }

  async #createConfigFile () {
    const config = {
      $schema: `https://schemas.platformatic.dev/${this.module}/${this.platformaticVersion}.json`,
      runtime: wrappableProperties
    }

    this.addFile({
      path: '',
      file: 'watt.json',
      contents: JSON.stringify(config, null, 2)
    })
  }

  async #readExistingFile (path, emptyContents = '', suffix = '') {
    const filePath = join(this.targetDirectory, path)

    if (!existsSync(filePath)) {
      return emptyContents
    }

    const contents = await readFile(filePath, 'utf-8')
    return contents + suffix
  }
}
